feat(channels): per-recipient reply pacing across 9 channels#6389
feat(channels): per-recipient reply pacing across 9 channels#6389singlerider wants to merge 7 commits intozeroclaw-labs:masterfrom
Conversation
First step of the zeroclaw-labs#6345 throttle. Adds the schema field on TelegramConfig as a proof-of-concept for the broader rollout. The field defaults to 0 (current behaviour preserved exactly) and is gated to 0..=3600 by validation that lands in a follow-up commit. Not in this commit (deliberately separate so the schema delta is reviewable in isolation): - Update every TelegramConfig struct-literal construction site to include the new field. CI will fail until this lands; that failure is the actionable list. - Mirror the field on every other channel that supports outbound sends (Discord, Slack, Matrix, Signal, WhatsApp, etc.) per the issue's "Mirror in every channel that supports outbound sends" acceptance criterion. - Range validation (0..=3600) at config load time. - Per-conversation outbound scheduler that honors the interval (queue replies, dispatch with tokio::time::Instant for monotonic clock). - Bounded per-conversation queue with overflow log + drop metric. - Documentation entry in the channel section of the book. Refs zeroclaw-labs#6345.
β¦literals CI surfaced 9 missing-field sites after the schema field add in the previous commit. All TelegramConfig struct literals across daemon, orchestrator, registry, config-mod, and three schema test fixtures now set reply_min_interval_secs: 0 explicitly so the build compiles. Sites fixed: - crates/zeroclaw-runtime/src/daemon/mod.rs:1107, 1253, 1273 - crates/zeroclaw-channels/src/orchestrator/mod.rs:11802 - crates/zeroclaw-runtime/src/integrations/registry.rs:872 - src/config/mod.rs:73 - crates/zeroclaw-config/src/schema.rs:12330, 13235, 16657 Refs zeroclaw-labs#6345.
Two fixes on top of the schema field add: * Drop the docs/book/theme/lang-switcher.js drive-by reorder. The file is generated by the mdbook build from locales.toml at the repo root, per its own header comment; manual edits are clobbered on next build. Reorder belongs in a standalone commit if at all. * The original docstring claimed the field had load-time validation and a per-(channel, peer-jid) scheduler. Neither lands in this commit (per the PR body's own scope boundary). Rewrite the docstring so what it claims and what the code does match. The intended range and the follow-up sequencing are still documented; the false present-tense claims are gone. Refs zeroclaw-labs#6345
Audacity88
left a comment
There was a problem hiding this comment.
Reviewed head a163123. I checked the fetched PR state, linked issue #6345, the existing #6345 triage comment, the full diff, the config-schema source around TelegramConfig, CI status at fetch time, and the relevant ZeroClaw review/docs/governance guidance. There are no prior PR comments, inline comments, or formal reviews. I did not run local cargo; CI is green. Local checks were git merge-tree --write-tree origin/master origin/pr/6389 and git diff --check origin/master...origin/pr/6389.
π’ What looks good β the schema slice is narrow and default-safe
The actual code change is small and low-behavior-risk: reply_min_interval_secs is added to TelegramConfig with #[serde(default)], and every existing TelegramConfig struct literal is updated with 0. That preserves existing behavior for users who do not set the new key. The follow-up commits also cleaned up the stale present-tense docstring claims and removed the unrelated generated docs reorder, which keeps this PR focused.
π΄ Blocking β Closes #6345 would close work this PR explicitly defers
The PR body says this is only the Telegram schema-field slice. It explicitly defers mirroring the field to other outbound channels, load-time range validation, and the per-channel/per-peer outbound scheduler. The linked issue #6345 is broader than this PR: its acceptance criteria require the field across supported channels, 0..=3600 validation, delayed outbound delivery, bounded queue behavior, and docs.
If this merges with Closes #6345, GitHub will close the tracker even though the actual pacing behavior has not landed and the remaining acceptance criteria are still open. That would make the work look complete when the PR itself says it is only the first step.
Please either change this PR to Refs #6345 and keep #6345 open, or open/link concrete follow-up issues for the remaining scheduler, validation, cross-channel, queue, and docs work before closing the tracker. If the intent is for this PR to fully close #6345, then the remaining acceptance criteria need to land here.
π‘ Warning β validation evidence should include command output
The PR says fmt, clippy, and check all pass locally, but the validation section summarizes the results without command output. CI is green, so I am not treating this as a code blocker, but the section should paste the command output tails or explain why local output is intentionally omitted.
WareWolf-MoonWall
left a comment
There was a problem hiding this comment.
Review β #6389 feat(config/channels): add reply_min_interval_secs to TelegramConfig
Verdict: comment
Reviewer: WareWolf-MoonWall
Head checked: a163123 (checks passing)
I read the full diff, the PR body, the existing review (Audacity88: CHANGES_REQUESTED), linked issue #6345, and cross-checked the schema change against the local TelegramConfig definition and all struct literal sites. I'm not approving over Audacity88's active block.
State of the queue: Audacity88 holds an active CHANGES_REQUESTED. No other reviews. I'm seconding Audacity88's block and adding one additional observation.
On Audacity88's block β I agree
The Closes #6345 trailer will auto-close the issue on merge. The PR body explicitly says this is only the first step: the scheduler, range validation, cross-channel mirroring, and docs are all deferred. The issue tracker closing would misrepresent the feature as complete when the agent doesn't yet pace anything β reply_min_interval_secs is added to the schema but nothing reads it yet at runtime.
Resolution path: change Closes #6345 to Refs #6345 in the PR body, and either open explicit tracking issues for the deferred work or add Depends on links when those PRs exist. This keeps the issue open as the active tracker for the full feature.
π΄ Blocking β cargo test was not run
Audacity88 flagged this as π‘; I'm treating it as π΄ here because this PR touches zeroclaw-runtime/src/ β a high-risk path per AGENTS.md. The validation section lists fmt, clippy, and check, but not cargo test. The changes are confined to struct literal construction sites in test code and the schema definition, so the tests almost certainly pass, and CI is green. But the template requires cargo test for code changes, and touching zeroclaw-runtime/src/daemon/mod.rs and zeroclaw-runtime/src/integrations/registry.rs β even in #[cfg(test)] blocks β is a code change, not a docs change. The PR is labeled risk: high; the evidence bar for that risk tier includes the full test sweep.
Please paste the cargo test tail before this lands.
π‘ Warning β cargo test skip is undocumented
The template says: "If any command was intentionally skipped, why." The validation section doesn't acknowledge the skip. Even if cargo test passes, the omission leaves a gap in the reviewable evidence record. Documenting "skipped because X" is what turns a gap into a deliberate decision.
π’ Praise β every struct literal site updated, default is zero
All TelegramConfig struct literal construction sites across schema.rs (tests), orchestrator/mod.rs (test), daemon/mod.rs (tests), integrations/registry.rs (test), and src/config/mod.rs (test) were found and updated with reply_min_interval_secs: 0. A zero-default means existing configs deserialize cleanly with no behavioral change. The #[serde(default)] annotation handles TOML configs that omit the new key. The diff is narrow and correct.
π’ Praise β docstring quality
The field-level docstring on reply_min_interval_secs is the right length and covers the right things: the default, the intended range, the identity-security rationale, and the deferred-scheduler caveat. It reads like a field that will be used, not like a placeholder.
π΅ Suggestion β add a telegram.md docs mention, even as a "coming soon" note
The field will surface in cargo mdbook refs automatically, but a user reading docs/book/src/channels/telegram.md won't know the field exists until someone looks at the config reference. Mentioning it β even briefly, e.g. "pacing: reply_min_interval_secs sets a floor on reply cadence; scheduler enforcement lands in a follow-up" β would make the staged intent visible in the channel docs rather than only in the schema reference.
π΅ Suggestion β consider a test for the new field's serde default
There are existing targeted tests for TelegramConfig serde behavior (telegram_config_ack_reactions_missing_defaults_to_none, etc.). Adding one for reply_min_interval_secs β verifying that a TOML input omitting the field deserializes to 0 β follows the established pattern and documents the contract explicitly. Not blocking, but consistent with the test style already present.
Template
The PR body is well-filled. The security section correctly notes that the field is inert at this commit. The rollback section is accurate. The Closes #6345 issue is the only template-level problem, and it's the same issue as the blocking item above.
Summary: Audacity88's block is correct. Change Closes to Refs, run cargo test and paste the tail, and this schema-only slice is ready for another look. The field itself is well-formed and the literal-site update is thorough.
|
@JordanTheJet β milestone alignment needed: this PR does not clearly fit within the scope boundary of any open milestone. Please advise on placement or deferral. |
β¦note - New `telegram_config_reply_min_interval_secs_missing_defaults_to_zero` guards the `#[serde(default)]` contract on the field. Pattern matches the existing `telegram_config_ack_reactions_missing_defaults_to_none`. - chat-others.md mentions the field on the Telegram block with a one-line note about the deferred scheduler tracked in zeroclaw-labs#6421. Per @WareWolf-MoonWall review suggestions.
theonlyhennygod
left a comment
There was a problem hiding this comment.
Reviewed current head ad800e8. I read the PR body, @Audacity88's CHANGES_REQUESTED at a163123, @WareWolf-MoonWall's COMMENTED at a163123, the full diff, the schema diff against TelegramConfig, every struct-literal call site touched, and the new test.
β
[resolved] Audacity88's blocker (Closes #6345) is addressed
The PR body now reads:
Refs #6345 (kept open as the active feature tracker; #6421 holds the deferred implementation work).
Filing #6421 explicitly for the deferred scheduler / range validation / cross-channel mirroring / queue / docs work β and changing Closes to Refs β is exactly the resolution path @Audacity88 asked for. #6345 stays open as the active feature tracker, and this PR no longer over-claims completion.
β
[resolved] WareWolf-MoonWall's blocker (cargo test evidence)
The validation evidence section now includes both targeted and workspace test runs:
$ cargo test -p zeroclaw-config --lib telegram
running 9 tests
... (all 9 pass, including the new telegram_config_reply_min_interval_secs_missing_defaults_to_zero)
$ cargo test --workspace --exclude zeroclaw-desktop --features ci-all --lib
# 1452 / 612 / 165 / 136 / 58 / 295 / ... per-crate suites: all passing
The new telegram_config_reply_min_interval_secs_missing_defaults_to_zero test is exactly the targeted serde-default test WareWolf-MoonWall suggested as a π΅ β it pins the #[serde(default)] contract so a regression that drops the attribute fails CI.
β [resolved] Telegram docs mention added
docs/book/src/channels/chat-others.md now includes:
reply_min_interval_secs(default0) sets a per-peer floor on reply cadence. Field is recognised by the schema; the per-channel pacing scheduler that actually delays delivery lands in a follow-up to #6345.
This is the staged-intent docs note WareWolf-MoonWall suggested. The "field exists but scheduler is deferred" framing is exactly right and matches the PR body.
π’ What's working well
- All
TelegramConfigstruct literal sites updated (schema tests, orchestrator test, daemon tests, integrations registry test,src/config/mod.rstest) β the0literal is consistent everywhere, no missed sites. - Field-level docstring is the right length and covers default, range, identity-security rationale, and deferred-scheduler caveat. Reads like a field that will be used, not a placeholder.
- Schema-only diff with zero-default means existing TOML configs deserialize cleanly with no behavioral change. Backward compat is byte-equivalent until #6421 lands.
Refs #6345+ spawn of #6421 is the right governance shape β keeps the tracker honest while allowing this slice to merge.
Verdict
All prior findings are addressed at HEAD. CI is green. The schema slice is narrow, default-safe, and well-tested.
Leaving as --comment rather than --approve because @Audacity88's CHANGES_REQUESTED is still on the books at a163123. @Audacity88, would you take another look at HEAD ad800e8? The Closes β Refs change + #6421 spawn directly resolves your block; if you agree, dismissing or converting your review clears the merge gate.
|
@Audacity88 β quick re-review ping. The |
β¦l + validation Closes zeroclaw-labs#6345 in full. - New `PacedChannel` wrapper in `crates/zeroclaw-channels/src/paced_channel.rs` that throttles outbound `send` (and `finalize_draft`) per-(channel, recipient) by the configured floor. `min_interval_secs == 0` returns the inner `Arc<dyn Channel>` unchanged so the default config pays no overhead. Streaming `update_draft` / `update_draft_progress` calls are NOT paced β pacing them would freeze the live preview. - Field mirrored from TelegramConfig to every other outbound channel that has a meaningful pacing surface: DiscordConfig, SlackConfig, MattermostConfig, WebhookConfig, IMessageConfig, MatrixConfig, SignalConfig, WhatsAppConfig. All `#[serde(default)]` so existing configs deserialize unchanged. - Orchestrator wraps each affected channel in `PacedChannel::wrap(...)` at construction time. The 7 push sites that have a Some-bound config var in scope (tg / dc / sl / mm / im / sig / wh) thread the value through. Other channels stay unwrapped because they don't carry the field. - `Config::validate()` enforces `0..=3600` on every `reply_min_interval_secs` value via a new `Config::collect_reply_min_interval_values()` helper. The upper bound catches typos that would otherwise wedge a channel for days (milliseconds-in-seconds is the obvious failure mode). - Tests: 4 PacedChannel unit tests (no `tokio::test-util` dep β uses short real-time intervals + state-only assertions) + 2 validate() tests pinning the 0..=3600 contract on both ends. - `docs/book/src/channels/chat-others.md` documents the field at the top with the wire contract: per-(channel, recipient), `0` is passthrough, draft updates not paced, different recipients independent.
Scope expanded to close and surpass.
WareWolf-MoonWall
left a comment
There was a problem hiding this comment.
Re-review β #6389 feat(channels): per-(channel,recipient) reply pacing β wrapper + 9 channels + validation
Verdict: --approve
Reviewer: @WareWolf-MoonWall
Head reviewed: 134614c (current HEAD; checks passing)
Prior review head: a163123 (COMMENTED)
Take-stock
@Audacity88's CHANGES_REQUESTED (at a163123) is DISMISSED. My prior COMMENTED review is not a block. theonlyhennygod reviewed an intermediate state at ad800e8 (COMMENTED). No active CHANGES_REQUESTED holds. I'm reviewing the expanded implementation fresh.
Prior findings β resolution status
β
[resolved] π΄ Closes #6345 premature
At a163123 this PR was a Telegram schema-only slice and the tracker would have closed prematurely. The resolution path here was not Refs + deferral β it was shipping the full implementation. The diff confirms all acceptance criteria from #6345 are present: PacedChannel wrapper, field on all 9 channels, orchestrator wrapping at every Arc::new(...) push site, Config::validate() range enforcement (0..=3600), 4 unit tests + 3 config tests, and the chat-others.md pacing section. Closes #6345 is now accurate and appropriate.
β
[resolved] π΄ cargo test evidence absent
The validation section now includes:
cargo test -p zeroclaw-channels --lib pacedβ 4 tests, all passingcargo test -p zeroclaw-config --lib reply_min_intervalβ 3 tests, all passingcargo test --workspace --exclude zeroclaw-desktop --features ci-all --libβ full workspace suite passing
High-risk-path evidence bar met.
β
[resolved] π‘ cargo test skip undocumented
cargo test was run. The gap is gone.
β
[resolved] π΅ Serde default test for reply_min_interval_secs
telegram_config_reply_min_interval_secs_missing_defaults_to_zero is present in the diff. It pins the #[serde(default)] contract so a regression that drops the attribute fails CI.
β [resolved] π΅ Channel docs mention
docs/book/src/channels/chat-others.md now has a top-level ## Pacing outbound replies (reply_min_interval_secs) section documenting the wire contract: per-(channel, recipient) floor, 0 is passthrough, draft updates not paced, recipients independent. More comprehensive than the per-channel telegram.md note I originally suggested β the right call given the field is on nine channels.
New findings
π‘ [warning] β Telegram-specific note in chat-others.md is now stale
Under the Telegram section, the new bullet reads:
`reply_min_interval_secs` (default `0`) sets a per-peer floor on reply cadence. Field is recognised by the schema; the per-channel pacing scheduler that actually delays delivery lands in a follow-up to #6345.
This was accurate when theonlyhennygod reviewed an intermediate state where the PR body said Refs #6345. It is not accurate now: PacedChannel IS the delivery mechanism and it ships in this PR. A user reading that bullet today will believe pacing is deferred when it is live.
Resolution: update the bullet to reflect the shipped state, e.g.:
reply_min_interval_secs(default0) sets a per-peer floor on reply cadence. Pacing is active: the orchestrator wraps the channel inPacedChannelso consecutive sends to the same peer wait at least N seconds.0is a zero-overhead passthrough.
Not blocking the approve β the top-level pacing section is accurate and describes the correct behavior. But the stale note should be cleaned up before or shortly after merge, since it will be the first thing a Telegram user sees in that section.
π‘ [warning] β size: XS label is stale
The diff is +680 -80. size: XS was appropriate for the original Telegram schema-only slice; the full implementation warrants at least size: M. Worth updating for changelog generation and queue hygiene β the label is used by the PR template for size signal.
What's working well
π’ Zero-overhead default path is correct
PacedChannel::wrap returns inner unchanged when min_interval_secs == 0. No allocation, no Arc::new, no Mutex. The default config pays exactly zero overhead for this feature. That's the right design β the constraint is "no penalty for the common case."
π’ Lock discipline in PacedChannel::send is correct
The lock is acquired to compute the sleep duration and update the per-recipient slot, then released before tokio::time::sleep. Multiple concurrent senders to different recipients proceed in parallel. Multiple concurrent senders to the same recipient each reserve their slot atomically and then sleep independently β the map records now + wait (the scheduled fire time) so a burst of N replies stacks to (N-1) * min_interval wall-clock correctly. There is no contention during sleep.
π’ Nine channels, every struct literal site updated
All nine channel configs (TelegramConfig, DiscordConfig, SlackConfig, MattermostConfig, WebhookConfig, IMessageConfig, MatrixConfig, SignalConfig, WhatsAppConfig) carry reply_min_interval_secs: 0 in every test struct literal across schema.rs, orchestrator/mod.rs, daemon/mod.rs, integrations/registry.rs, src/config/mod.rs, and the channel-specific test modules. No missed sites visible in the diff.
π’ Config::validate() upper-bound test pins the typo-prevention contract
validate_accepts_reply_min_interval_at_upper_bound (3600 passes) and validate_rejects_reply_min_interval_above_one_hour (3601 fails) pin the boundary that catches the milliseconds-in-seconds typo before runtime. The 60000 β 16-hour-wedge failure mode is now a config validation error, not a silent runtime regression.
Summary
All prior findings from my COMMENTED review at a163123 are resolved. The full pacing feature is correctly implemented, tested, and documented. The two π‘ items above (stale doc note, label) are cleanup and do not block merge. CI is green.
Approving. Once the Telegram-specific doc note is updated, this is complete.
The Telegram bullet pre-dated the in-PR pivot to ship pacing in the same PR; it still described the field as schema-only with delivery deferred. Pacing is active here. Point to the top-level Pacing section for the full contract.
WareWolf-MoonWall
left a comment
There was a problem hiding this comment.
Re-review β PR #6389 Β· feat(channels): per-recipient reply pacing across 9 channels
Verdict: APPROVE
Reviewed at commit db82da2.
Resolved since prior review
β
Orchestrator integration complete. collect_configured_channels now wraps every Some-bound channel construction in PacedChannel::wrap(..., <config>.reply_min_interval_secs). The prior head shipped the PacedChannel implementation and config schema; this head wires it into all nine channels at the construction sites. The implementation is complete end-to-end.
β
Stale doc bullet fixed. The PR body confirms the chat-others.md Telegram bullet was updated to reflect that pacing is live in this PR (not "landing in a follow-up"). Verified in the updated PR body.
π’ The reply_min_interval_secs == 0 passthrough optimisation (PacedChannel::wrap returns the inner Arc unchanged when the interval is zero) means the default config pays zero overhead β no wrapper allocation, no Mutex contention. This is the right design for an opt-in feature.
π’ Matrix test config updated to include reply_min_interval_secs: 0 β confirms the struct initialisation pattern is consistent across all channels with exhaustive field coverage.
One non-blocking cleanup remaining
π‘ size: XS label is stale β the diff is ~1 270 lines; warrants size: M or size: L. Not a merge blocker, but a quick sidebar edit before landing keeps the metadata useful.
No active CHANGES_REQUESTED from any reviewer. Approving.
Summary
masterreply_min_interval_secs: u64field (default0, range0..=3600). The orchestrator wraps each Some-bound channel in aPacedChannelso consecutive outbound replies to the same peer wait at least the configured floor before firing.PacedChannel(incrates/zeroclaw-channels/src/paced_channel.rs) wrapsArc<dyn Channel>and throttlessendand the terminalfinalize_draftper recipient. Streamingupdate_draft/update_draft_progressare NOT paced; pacing them would freeze the live preview.min_interval_secs == 0returns the innerArcunchanged, so the default config pays zero overhead (no wrapper, noMutex).reply_min_interval_secsfield is added toTelegramConfig,DiscordConfig,SlackConfig,MattermostConfig,WebhookConfig,IMessageConfig,MatrixConfig,SignalConfig,WhatsAppConfig. All#[serde(default)], so existing configs deserialise unchanged. Orchestrator wiring: eachSome-bound channel push site incollect_configured_channelswraps itsArc::new(...)inPacedChannel::wrap(..., <var>.reply_min_interval_secs)at construction.Config::validate()enforces0..=3600via a newConfig::collect_reply_min_interval_values()helper that walks everyOption<XConfig>carrying the field. The one-hour upper bound catches the milliseconds-in-seconds typo before runtime; without it a60000would wedge a channel for 16 hours.docs/book/src/channels/chat-others.mdcarries a top-level "Pacing outbound replies" section that documents the wire contract (per-(channel, recipient),0is passthrough, draft updates not paced, different recipients independent).0preserves byte-for-byte behaviour for operators who do not opt in.XConfigtypes. Default behaviour unchanged. Whenreply_min_interval_secs > 0, consecutive replies to the same recipient are delayed by at leastNseconds; replies to different recipients on the same channel are independent. Lock discipline inPacedChannel::sendreleases the per-recipient slot before sleeping, so concurrent senders to different recipients run in parallel.Validation Evidence (required)
cargo +nightly fmt --all -- --check: clean (no output).cargo clippy ... --features ci-all -- -D warnings:Finished dev profile [unoptimized + debuginfo] target(s)(zero warnings).cargo test -p zeroclaw-channels --lib paced:cargo test -p zeroclaw-config --lib reply_min_interval:cargo test --workspace --exclude zeroclaw-desktop --features ci-all --lib: all per-crate suites passing.Test,Check (all features),Lint, andSecurity.PacedChannel::send: the lock is acquired to compute the sleep duration and update the per-recipient slot, then released beforetokio::time::sleep. Multiple concurrent senders to different recipients proceed in parallel; multiple concurrent senders to the same recipient each reserve their slot atomically and then sleep independently. The map recordsnow + wait(the scheduled fire time), so a burst ofNreplies stacks to(N-1) * min_intervalwall-clock correctly.reply_min_interval_secs: 0in every test struct literal acrossschema.rs,orchestrator/mod.rs,daemon/mod.rs,integrations/registry.rs,src/config/mod.rs, and the channel-specific test modules.chat-others.mdmatches shipped behaviour (the bullet that previously said "field is recognised by the schema; pacing scheduler lands in a follow-up" was updated when pacing was inlined into this PR).Security & Privacy Impact (required)
NoNoNoNoCompatibility (required)
Yes. Default0preserves current behaviour byte-for-byte across every channel. Existing configs deserialise cleanly without the key set on any of the nine channels.Yes(additive). Newreply_min_interval_secskey on[channels.telegram],[channels.discord],[channels.slack],[channels.mattermost],[channels.webhook],[channels.imessage],[channels.matrix],[channels.signal],[channels.whatsapp].reply_min_interval_secs = N(0..=3600) under any of the nine channel sections.Rollback (required for
risk: mediumandrisk: high)git revert <merge-sha>against master. Configs that adopted any of the new keys deserialise unchanged after revert (the key becomes unknown and is ignored).reply_min_interval_secs = 0(the default) is itself a passthrough toggle. Operators can disable pacing per channel without rolling back the PR.0..=3600validator). Pacing returns to the prior immediate-send behaviour with no migration step on revert.